Part 4|第 18/30 篇
今日要做的事: 把 Rule → Gemini 候選 → validate 收成一條決策流,並把採用和煮完扣庫存拆開。
今天要解決的目的: 使用者只是先記下打算做的菜時,LifeFlow 的 pantry 不能先被改掉。
昨天工具已經能把冰箱和食譜拿回來,兩爐食譜也擋得住。今天卡住的不是推薦,而是推薦之後的五分鐘:我剛按下「採用」,公司群組就說晚餐已經幫大家訂好了。
如果豆腐已經先扣掉,取消時要補回多少?雞蛋打了沒?這些事系統根本不知道。按鈕一按就扣庫存,資料會比沒記還糟。
| 項目 | 內容 |
|---|---|
| 產出 | 決策流 decide、採用/煮完狀態、六項本機時序測試、一次 Gemini 端到端實跑 |
| Google 服務 | Gemini API Structured Output(responseSchema 鎖住候選格式) |
| 沿用 | Day 12 validate、Day 16 的五支工具與單口爐 fixture |
| 不允許 | 採用即扣庫存;用模型輸出的 hard_constraints_ok 代替伺服器 validate |
| 今日範圍 | Firestore transaction 還沒接,扣庫存先在本機記憶體裡驗時序 |
| 固定設備 | induction_cooktop、deep_wok、tamagoyaki_pan、microwave;burners=1 |
| 指標 | hard_ok、not_adoptable、outflow_once、pantry_delta_valid |
下面只摘會影響時序的程式。node test.js 用固定輸出反覆撞失敗案例,不花配額;node live.js 才真的請 Gemini 出候選,再走完整條流程。
Day 10 已經在畫面和 Firestore 證明「採用不扣 qty」。那時回答的是按鈕當下不能做什麼;今天往後補成伺服器契約,回答何時才准扣、網路重送怎麼不重扣。

今天實際跑的是:Gemini 用 Structured Output 交候選 → 伺服器 decide 重算 hard → 通過的才能 adopted → 收到 complete_cook 才 outflow。UI 不重做,Firestore transaction 也還沒接;先把最容易寫髒庫存的時序鎖住。
Rule → Gemini → validate 回答的是「這道菜能不能顯示」。它沒有回答「使用者是否真的煮了」。
昨天的 settleMeal 只處理外食扣不扣,沒有處理按鈕按下去的時間。今天至少要分開四個事實:
recommended:validate 通過,進得了清單
adopted:使用者表示打算做
cooked:確認已煮完
outflowed:cook_run、pantry 與 tip 已寫回
adopted 還沒到 cooked。少這一層的話,之後 Pilot 的採用率、實際完成率、pantry 會被同一顆按鈕污染。
外食仍是另一條路:food_source=eat_out 只在吃完後寫 today_meals,不扣家裡的庫存。

1. 讀 authoritative profile、pantry、today
2. compile → 過敏、設備、爐口
3. Gemini Structured Output 交出候選方案
4. 伺服器 validate;只留下 hard_ok
5. 保存這次 request 看得到的 options
Gemini 會在每個方案上填 hard_constraints_ok。那一欄只代表模型自己這麼說,伺服器仍用 Day 12 的 validate 重算。重算沒過的方案不進 options,後面也不能被採用。
若所有候選都不合格,合法結果是沒有方案,而不是挑一個最不犯規的露出來:
{
"status": "NO_FEASIBLE_OPTION",
"options": [],
"next_action": "請使用者明確放寬可變條件"
}
過敏與飲食禁忌不能列進可放寬條件。
自煮的順序是:
推薦 → 採用 → 實際煮完 → 建立 cook_run
→ 扣 pantry、寫自煮 today_meals、產生一張 tip_card
採用當下只寫意向:
{
"request_id": "req_adopt",
"option_id": "tofu_scramble",
"status": "adopted",
"adopted_at": "SERVER_TIMESTAMP"
}
這裡沒有 pantry_delta,也沒有 tip_card。豆腐還是 0.5 盒。

圖上的 cooked 是完成事件本身。程式不單獨存一筆 cooked:complete_cook 進來才代表煮完,同一筆草稿裡接著寫 outflowed。用量對不上庫存時,整筆草稿作廢,cook_run 也不留下。
| 檔案 | 負責 |
|---|---|
live.js |
請 Gemini 用 Structured Output 出候選,再走完整條流程 |
decide.js |
compile、重算 validate、沒有合格方案就停 |
outflow.js |
採用、煮完、重送、外食、超量拒絕 |
test.js |
六項時序,用固定模型輸出,不打 API |
Day 16 是讓 Gemini 自己叫工具讀資料。今天換個分工:資料由伺服器用同一組工具先讀好,Gemini 只負責「從這些食譜裡挑候選、說理由」,輸出格式用 responseSchema 鎖死:
const responseSchema = {
type: "OBJECT",
properties: {
options: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
option_id: { type: "STRING" },
recipe_id: { type: "STRING", enum: ["tofu_scramble", "two_burner_soup"] },
title: { type: "STRING" },
reason: { type: "STRING" },
hard_constraints_ok: { type: "BOOLEAN" },
},
required: ["option_id", "recipe_id", "title", "reason", "hard_constraints_ok"],
},
},
},
required: ["options"],
};
送出時把 schema 放進 generationConfig:
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": process.env.GEMINI_API_KEY,
},
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: { responseMimeType: "application/json", responseSchema },
}),
}
);
recipe_id 用 enum 限制,回傳值只能是這兩個既有 id。拿回來之後,食譜的食材、步驟、設備一律換回伺服器 get_recipe 讀到的版本;模型只貢獻 title、reason 和它自己判斷的 hard_constraints_ok。這樣 validate 看的是原始食譜,不是模型轉述後的版本。
即使候選帶著 hard_constraints_ok: true,過不了爐口檢查也不會出現在清單裡:
const judged = modelOptions.map((option) => {
const verdict = validateRecipeOption(option, context);
return {
option_id: option.option_id ?? option.recipe_id,
hard_ok: verdict.hard_ok,
issues: verdict.issues.map((item) => item.code),
model_said_ok: option.hard_constraints_ok === true,
};
});
const options = judged.filter((item) => item.hard_ok);
採用只查這份已經過濾過的清單。hard_ok !== true 的方案回 NOT_ADOPTABLE,pantry 一個字都不寫。
煮完才接受實際用量。run_id 由 request_id 和 option_id 組成,同一件完成事件再送一次,就回原來的 cook_run:
const runId = `run_${event.request_id}_${event.option_id}`;
if (store.cookRuns[runId]) {
return { ok: true, replay: true, run_id: runId, pantry_writes: 0 };
}
自煮扣庫存前先複製一份草稿。用量超過現有 qty、或單位對不上,函式直接回來,草稿不寫回。這是 Firestore transaction 以後要保證的同一件事:cook_run 寫不進去時,pantry 不能先被改。今天的測試在本機記憶體跑,沒有連上 Firestore。
外食不進這段扣庫存。food_source=eat_out 只追加 today_meals,pantry_delta 是空陣列。
先在本機執行:
node test.js
這裡的模型輸出是固定的,刻意讓兩爐湯帶著 hard_constraints_ok: true,專門測模型蓋錯章的情況。六項依序看:
第一題:模型說合格,伺服器不認。 model_said_ok: true,但 hard_ok: false,清單只剩 tofu_scramble。硬要採用兩爐湯,回 NOT_ADOPTABLE。

第二題:一個都不合格,就回沒有方案。 候選只剩兩爐湯時,options 是空的,不會挑一個最不犯規的露出來。

第三題:採用之後,冰箱沒動。 狀態是 adopted,豆腐仍是 0.5 盒、雞蛋 4 顆,tips: 0、pantry_writes: 0。這就是開頭那個「剛按採用就被通知晚餐訂好了」的情況:什麼都不用補回。

第四題:煮完才扣,重送不重扣。 first 扣掉 0.5 盒,pantry_writes: 1、tips: 1。同一個完成事件再送一次,replay: true,run_id 相同,pantry_writes: 0,豆腐停在 0,tip 也還是 1 張。

第五題:用量對不上,整筆不寫。 冰箱只有 0.5 盒卻申報用掉 2 盒,回 PANTRY_DELTA_INVALID。豆腐仍是 0.5,cook_runs 是空陣列,不會出現「扣了一半」的庫存。

第六題:外食只記一筆餐。 today_meals 多了一筆 eat_out,pantry_delta: []、pantry_writes: 0,冰箱數字和一開始一樣。最後一行是「這輪沒有呼叫 Gemini,也沒有寫 Firestore」。

規則鎖住之後,才讓 Gemini 真的出候選。金鑰放在本機 .env 的 GEMINI_API_KEY:
node live.js

gemini-3.8-flash 先回了一次忙碌,重試後才回兩個候選:豆腐蛋炒 hard_constraints_ok: true,理由寫「符合單爐口限制且食材無過敏原,設備齊全」;兩爐湯 false,理由寫「需要同時使用兩個爐口,不符合廚房僅有一個爐口的硬性限制」。用量 total_tokens=1185,其中思考 551、回答 120,思考是回答的四倍多。
截圖中間那段 TLS 警告,是我本機網路有憑證攔截,執行前關掉了 Node 的憑證驗證。只在自己電腦上跑實驗時這樣做,部署出去的服務不要設。

這一輪模型自己也判對了,兩爐湯沒有被蓋成合格。伺服器的 decide 結果一致:清單只剩 opt_tofu_scramble,兩爐湯以 BURNER_CONCURRENCY_EXCEEDED 放進 blocked,model_said_ok: false。
模型判對,不代表 validate 可以拿掉。今天這一次它對了,4.1 的第一題就是在測它哪天把兩爐湯填成 true 的時候:擋下的原因碼一樣是 BURNER_CONCURRENCY_EXCEEDED,而且那筆不能被採用。
採用 opt_tofu_scramble 之後,豆腐還是 0.5 盒,pantry_writes: 0。

送出 complete_cook 後才建立 run_req_live_opt_tofu_scramble,pantry_delta 記下用掉 0.5 盒,豆腐變 0,pantry_writes: 1。從 Gemini 出候選到扣庫存,同一條線跑完;只有最後寫回還在本機記憶體,Firestore transaction 留到後面接。
比較容易翻車的寫法:
eat_out 跟自煮共用會扣庫存的那條路我最在意的是第二種。假設我採用豆腐蛋炒後臨時改吃公司便當,正確結果不是「取消後補回 0.5 盒」,而是 pantry 從頭到尾都維持 0.5。沒有發生過的 Outflow,不需要補償交易,也不該留下假的 tip。
hard_constraints_ok 不能讓方案上桌,就算這次它填對了。complete_cook,同一筆裡寫出 cook_run,才扣 pantry、產生 tip_card。food_source=eat_out 只寫 today_meals。下一篇: 推薦能通過還不夠。leftover、balance、goal 的理由要能回指同一份 LifeFlow,不能只靠一句「因為比較健康」。
recommended → adopted → (complete_cook) → outflowed
recommended → skipped
adopted → abandoned
cooked 不另存一筆文件,它就是 complete_cook 這個事件。目前程式只有採用與煮完兩個入口;skipped、abandoned 先留在狀態契約裡。等入口補上時,它們只能改狀態,不能呼叫 completeCook,因此不會扣 pantry,也不會產生 tip。
保存 request_id、看得到的 options、被擋下的方案與原因碼、hard_constraint_ids、模型版號。實際用量只放在 cook_run,不回寫去改當初那份推薦。